注意我們的 Flutter 專案結構,會注意到有個檔案叫 lib/main.dart
裡面包含了一個類別 MyApp
。這裡要提到的是,不像一些其他程式語言,檔案名稱須對照類別名稱。Dart 並沒有強制需要這麼做。
另外,在 Dart 將多個相關的類別,Enum,函式放在同一個檔案構成一個函式庫是很常見的做法。這種做法可以通過將某些類別開頭加入 _
底線達成 private
私有類別,而其他類別可以被外部存取進而實現封裝。
我們後續探討 Widget 時,會很常看到兩個類別在同一個檔案例如一個公開的 StatefulWidget 類別和一個私有的 State 類別。
當然我們不會在單一檔案中建構整個應用程式 ,如此一來程式碼會變得難以維護,妨礙封裝,甚至造成 IDE 效能問題。因此我們需要一個從其他檔案引用的方式。通過 import
可以完成我們的需求。
幾乎任何語言都支援匯入的方式,從其他檔案匯入類別,packages,plugins 等需要的功能。如果觀察 main.dart
會看到
import 'package:flutter/material.dart';
在這個例子,material.dart
檔案被匯入,在這個檔案中的類別和函式你都可以使用。這個檔案包含很多基本 Flutter 支援的功能,大部分的 Flutter 應用程式都會匯入。
本節我們已經了解了 Dart 類別的結構,包含類別成員,方法。然後,我們了解了不同建構子實例化物件。還討論了一些特殊使用類別的方式如抽象類別,介面,Mixins。最後學習了如何分享程式碼到不同檔案。
列舉是一種用來表示一組固定常數的資料類型。Dart 也一樣,通過 enum
關鍵字可以定義 enum
。首先,基本的 enum 使用:
enum PersonType { student, employee }
enum Season { spring, summer, autumn, winter }
列舉提供了一種結構化且易讀的方式來表示固定選項。每個列舉值都有一個對應的索引,從 0 開始。你可以把它想成是一組常數變數的集合,本來需要各自宣告變數和賦予常數值,現在我們可以直接使用 PersonType.student
來賦值。主要可以讓程式增加可讀性和好維護,會比使用字串或數字詮釋易懂。
下面我們來看看其如何運作,首先加入一個欄位到之前定義的 Person
類別
enum PersonType { student, employee }
class Person {
String name;
PersonType type;
Person(this.name, this.type);
}
void main() {
print(PersonType.values); // [PersonType.student, PersonType.employee]
Person person = Person("andyyou", PersonType.student);
print(person.type); // 輸出: PersonType.student
print(person.type.index); // 輸出: 0,因為 `student` 是列舉中的第一個值
// print(describeEnum(PersonType.employee));
// employee
}
當我們呼叫 PersonType.values
時會如註解一樣輸出,還可以呼叫其 index
。一般來說,我們不會依靠索引,而是直接使用 PersonType.employee
這樣來表示值。
另外在 Flutter 中支援 describeEnum
方法來獲取列舉值的字符串表示 employee
。
import 'package:flutter/foundation.dart';
enum PersonType { student, employee }
print(describeEnum(PersonType.employee)); // 輸出: employee
當需要儲存在資料庫時,通常建議使用字串的方式
person.type.name;
// 或者
person.type.toString().split('.').last();
要從字符串轉回列舉值,可以使用以下方法:
PersonType stringToPersonType(String typeString) {
return PersonType.values.firstWhere((type) => type.toString().split('.').last == typeString, orElse: () => throw Exception('Unknown type string: $typeString'));
}
常見使用 enum
的情形如 switch
條件式,因為如果 switch
的條件處理沒有包含全部項目的話會觸發警告。這非常適合於已經寫好了處理所有不同 enum
值的程式,然後又增加另一個值的情況 —— 編譯器將警告你,你沒有完整的處理全部的選項。
swtich (person.type) {
case PersonType.student:
print('Learn');
break;
case PersonType.employee:
print('Work');
break;
}
如果我們之後加入新的類型如 PersonType.retired
那麼 switch
那邊就會有警告。
<>
語法是用來指定型別。如果你看過第二章節的 List 和 Map 範例,注意到我們並沒有指定它們應該包含的型別。這是因為型別的資訊是可選的,Dart 可以基於元素推斷型別。
泛型可以幫助開發者維護和控制集合的行為。當我們的集合沒有使用型別時,加入正確的元素是我們的責任,但這確實可能導致 Bug 如果我們加入錯誤的型別導致錯誤的推斷。
例如下面範例,我們有一個 placeNames
變數。我們希望是一個名稱列表,但不幸的,在沒有使用泛型的情況下我們可以加入任何值,如果不小心加入 number
。那麼就容易導致錯誤。
main() {
List placeNames = ["Middlesbrough", "New York"];
placeNames.add(1);
print("Place names: $placeNames");
}
如果此時我們使用泛型,那麼程式碼在編譯時期就會出錯,進而提早發現錯誤
main() {
List<String> placeNames = ['Taipei', 'Changhua'];
placeNames.add(1); // 錯誤
}
在 List 和 Map 的範例中,我們使用了 []
和 {}
語法,它們就是字面量的格式。泛型有一個取代前面語法的方式,我們可以在初始化的時候把 <Type>
加到 []
或 {}
的前面:
main() {
var placeNames = <String>['Middlesbrough', 'New York'];
var landmarks = <String, String> {
'Middlesbrough': 'Transporter bridge',
'New York': 'Statue of Liberty'
}
}
上面的例子看起來似乎在 []
前面加入泛型有點多餘,因為 Dart 會自己推斷型別,然而在一些情況下,這還是很重要的,例如初始的集合是一個空集合
var stringArray = <String>[];
如果我們沒有指定型別,那麼它可以加入任何資料,進而推斷錯誤的型別。
如同我們在前面學習的 Null 安全,如果變數可以為 null
那麼必須在變數宣告。在泛型也一樣,如果集合允許為 null
。
例如假設我們的 landmarks
Map 我們允許一些地點沒有地標。我們可以宣告,然後當我們存取 Map 實例時,就有可能為 null
。
main() {
var landmarks = <String, String?> {
'Middlesbrough': 'Transporter bridge',
'New York': 'Statue of Liberty',
'Barnmouth': null,
}
}
我們指定了值可以為 null
然後加入了新的資料。到此,我們已經學習了關於型別安全的部分。
Dart 是單執行緒程式語言,意思是所有程式碼都在同一個執行緒執行類似於 Nodejs。簡單說,這意味著程式碼如果執行長時間的操作可能會阻塞執行緒例如 I/O 操作或者 HTTP 請求。例如一個很慢的 HTTP 請求卡住操作,在使用者持續嘗試操作介面時可能會造成問題。應用程式會看起來像是不動了,不回應了。
雖然 Dart 是單執行緒,但可以執行非同步操作。這允許程式碼觸發一個操作,然後繼續執行其他任務,等操作完成就回到這個操作。要呈現這些非同步操作,Dart 使用 Future
物件結合 async
await
關鍵字。
當我們的程式呼叫一個需要長時間執行的任務時,我們不希望它阻塞其他如 UI 操作,此時,我們可以使用 async
註記該方法為非同步。這會讓呼叫方法的地方了解該操作會花費長時間,且在等待結果時不應該阻塞其他執行。即呼叫了該方法之後繼續執行其他程式碼。
但我們確實需要取得這個長時間執行的結果,因此我們需要它處理完畢時回到呼叫的地方。為此,我們需要指定當回應的時候回到剛剛呼叫的地方,這時我們要使用 await
關鍵字。async
和 await
的差異是 async
是宣告該方法為非同步,await
用於呼叫方法等待回應的地方。
假設我們已經使用 async
宣告方法為非同步,然後我們用 await
指定了完成任務時回到的地方,但這個方法會回傳什麼回來呢?在 Dart 中 Future<ResultType>
物件表示會在未來的某個時間點會提供值。可以用來宣告方法回傳未來的結果 - 表示一個方法回傳 Future<ResultType>
物件將不會立刻取得結果,而是在之後的某廣告時間點。類似於 JavaScript 的 Promise。
為了理解非同步,讓我們從一個簡單的同步程式開始:
import 'dart:io';
void longRunningOperation() {
for (int i = 0; i < 5; i++) {
sleep(Duration(seconds: 1));
print('長時間操作: $i');
}
}
main() {
print('開始長時間操作');
longRunningOperation();
print('繼續執行主程式');
for (int i = 10; i < 15; i++) {
sleep(Duration(seconds: 1));
print('主程式: $i');
}
print('主程式結束');
}
這裡我們有一個 main
函式,呼叫了一個長時間操作的函式。我們使用 sleep()
來暫停執行,這個函式需要匯入 dart:io
套件。
如果你執行上面程式碼會得到結果如下:
開始長時間操作
長時間操作: 0
長時間操作: 1
長時間操作: 2
長時間操作: 3
長時間操作: 4
繼續執行主程式
主程式: 10
主程式: 11
主程式: 12
主程式: 13
主程式: 14
主程式結束
注意到 longRunningOperation()
會阻塞 main
的執行,這就是一般的同步執行,這種情形在實際應用程式執行的狀況下,UI 會被卡住導致使用者體驗不佳。
現在,讓我們將其改為非同步版本
import 'dart:io';
import 'dart:async';
Future<void> longRunningOperation() async {
for (int i = 0; i < 5; i++) {
sleep(Duration(seconds: 1));
print('長時間操作: $i');
}
}
main() {
print('開始長時間操作');
longRunningOperation(); // 注意到這裡沒有使用 await
print('繼續執行主程式');
for (int i = 10; i < 15; i++) {
sleep(Duration(seconds: 1));
print('主程式: $i');
}
print('主程式結束');
}
現在我們把 longRunningOperation()
變成非同步了,並且回傳型別使用 Future
,當我們再次執行會看到結果:
開始長時間操作
長時間操作: 0
長時間操作: 1
長時間操作: 2
長時間操作: 3
長時間操作: 4
繼續執行主程式
主程式: 10
主程式: 11
主程式: 12
主程式: 13
主程式: 14
主程式結束
結果依舊沒變!!雖然我們將 longRunningOperation()
加上了 async
,但我們依然使用了會同步的 sleep()
函式,執行緒還是會被塞住。我們得使用另一個非同步,但效果和 sleep
類似的方法叫 Future.delayed()
,讓我們在更新一次範例:
import 'dart:io';
import 'dart:async';
Future<void> longRunningOperation() async {
for (int i = 0; i < 5; i++) {
await Future.delayed(Duration(seconds: 1));
print('長時間操作: $i');
}
}
void main() { ... }
現在我們使用了 Future.deplayed
,它是非同步的了。我們希望繼續執行其他程式,然後當加入 await
的地方完成之後回來。現在執行緒被解放了,但當該函式完成時會回到 await
。再次執行的結果如下:
開始長時間操作
繼續執行主程式
主程式: 10
主程式: 11
主程式: 12
主程式: 13
主程式: 14
主程式結束
長時間操作: 0
長時間操作: 1
長時間操作: 2
長時間操作: 3
長時間操作: 4
我們不再只有同步的程式碼了(即程式碼完全依序執行),如同我們上面完成的,執行順序變了。在上面範例,變化發生在 longRunningOperation()
呼叫的時候我們使用了 await
搭配非同步函式 Future.delayed()
,longRunningOperation()
函式會在該位置暫停,然後在延遲 1 秒操作完成並回應時恢復繼續執行。
在延遲之後, main()
函式已經繼續執行;並且沒有等待 longRunningOpertaion()
執行完成, longRunningOperation()
會在 main
執行完之後繼續。如果我們將 main
也變成非同步並 await longRunningOperation()
,此時 main
將會被暫停等到其執行完畢就跟一開始的同步版本一樣。嘗試另一個實驗在 main
將 sleep
換成 Future.delayed
main() async {
print('開始長時間操作');
longRunningOperation();
print('繼續執行主程式');
for(int i = 10; i < 15; i++) {
await Future.delayed(Duration(seconds: 1));
print('主程式: $i');
}
print('主程式結束');
}
結果
開始長時間操作
繼續執行主程式
長時間操作: 0
主程式: 10
長時間操作: 1
主程式: 11
長時間操作: 2
主程式: 12
長時間操作: 3
主程式: 13
長時間操作: 4
主程式: 14
主程式結束
要理解這個輸出結果,我們需要了解 Dart 在同一個執行緒執行 2 個非同步方法。兩個函式都是非同步,但這並不是意味著平行。Dart 一次執行一個操作;因此只要一個操作在執行中就不會被其他程式碼中斷。這個執行是由 Dart 的 Event Loop 控制的,管理 Future
和非同步程式碼。
因此在我們範例中,longRunningOperation
函式被執行,當碰到 Future.delayed()
呼叫時就釋放執行緒的控制,執行緒可以接著執行 main
直到 main
的操作又遇到 Future.delayed
,換 main
釋放控制回到 longRunningOperation
反覆執行。
雖然看似為平行,Dart 也確實有平行的操作 - 即多個程式碼同時執行。但這裡的不屬於平行。要真的實現平行操作需要使用 Isolates。
async
關鍵字用於標記一個函式為非同步函式。這允許在函式內使用 await
關鍵字,並且該函式會自動返回一個 Future
物件。await
關鍵字用於等待一個 Future
完成並取得其結果。它只能在 async
函式內使用,可以讓非同步程式碼看起來像同步程式碼,提高可讀性。Future
是一個表示非同步操作結果的物件。它代表了一個尚未完成的計算或操作,這個操作將在未來的某個時間點完成並提供一個值(或錯誤)。Future.delayed
是一個常用的 Future
建構子,用於建立一個延遲執行的非同步操作。它接受一個 Duration
參數來指定延遲時間,以及一個可選的回調函式。這裡我們的範例是為了強調 async
、await
和 Future
(如 Future.delayed()
)須組合使用才能實現非同步,單純光靠 async
await
並無法非同步。也希望帶出正確的理解。
你可能已經知道 Dart 可以真的達成平行操作。Dart Isolate 就是設計來達成這個目的的。每一個 Dart 應用程式至少都是由一個 Isolate 實例組成的 - 核心 Isolate 實例執行全部應用程式的程式碼,因此要平行執行程式碼必須要建立一個 Isolate 實例,讓它和核心 Isolate 實例平行運作。
Isolate 可以想成是一種執行緒。但他們彼此無法分享,就如同其名稱。表示它們互相不分享記憶體,所以我們不需要使用其他鎖定和其他執行緒同步的技術。Isolate 的溝通需要傳送和接收 - 需要交換資訊。
讓我們使用 Isolate 修改上面的實作
import 'dart:isolate';
Future<void> longRunningOperation(String message) async {
for (int i = 0; i < 5; i++) {
await Future.delayed(Duration(seconds: 1));
print("$message: $i");
}
}
void main() async {
print("開始長時間操作");
Isolate.spawn(longRunningOperation, "哈囉");
print('繼續執行主程式');
for (int i = 10; i < 15; i++) {
await Future.delayed(Duration(seconds: 1));
print("主程式: $i");
}
print("主程式結束");
}
如你所見,我們微調了程式碼。
longRunningOperation
函式還是一樣,但我們加入了 message
,這個參數可以使用 Isolate
傳遞Isolate
開始執行,我們使用了 spawn()
傳入兩個參數,一個函式和參數dart:isolate
執行程式碼會得到類似的輸出
開始長時間操作
繼續執行主程式
主程式: 10
哈囉: 0
主程式: 11
哈囉: 1
主程式: 12
哈囉: 2
主程式: 13
哈囉: 3
主程式: 14
主程式結束
哈囉: 4
兩個函式一樣是交錯運行,但這一次 main
比 longRunningOperation()
先執行。前一個例子是執行緒在遇到 await Future.delayed()
之前不會釋放控制權,而 spawn
操作是建立一個獨立的非同步,讓 main
立刻可以執行到 Future.delayed()
。此外,請注意這裡沒有我們在前一個例子中看到的情況,即每個函數在 await
點將控制權交給另一個函數。這些實際上是兩個獨立運行分開的執行緒。
為了儘量補充開發 Flutter 所需的一些知識,我們得再介紹一個概念 - 「程式碼生成」,這是是一種自動產生程式碼的技術。在 Dart 和 Flutter 中,我們經常使用它來減少重複性的工作,提高開發效率。
程式碼生成主要是透過 Dart 的能力實現的,利用了語言的註解系統 @
和映射能力實現。開發者透過特定的註解標記,生成程式碼的類別或方法。生成的程式碼通常會被儲存在 .g.dart
檔案中,並透過 part
加入到原始檔案。
例如我們有一個 User
類別:
class User {
final String name;
final int age;
User(this.name, this.age);
}
如果我們要將這個類別轉換成 JSON 格式,或從 JSON 格式轉回來,我們需要寫很多重複的程式碼:
class User {
final String name;
final int age;
User(this.name, this.age);
Map<String, dynamic> toJson() {
return {
'name': name,
'age': age,
};
}
factory User.fromJson(Map<String, dynamic> json) {
return User(
json['name'] as String,
json['age'] as int,
);
}
}
這樣的程式碼寫起來很繁瑣,而且容易出錯。
這時,我們可以使用 json_serializable
套件來自動生成這些程式碼。首先,我們需要在 pubspec.yaml
中加入:
dependencies:
json_annotation: ^4.8.1
dev_dependencies:
build_runner: ^2.4.6
json_serializable: ^6.7.1
然後,我們修改 User 類別,加入一些特殊的註解:
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
@JsonSerializable()
class User {
final String name;
final int age;
User(this.name, this.age);
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
然後,可以執行以下指令來生成程式碼:
$ flutter pub run build_runner build
到此,我們學習了 Dart 如何結合物件導向,從而討論了 Dart 的類別包含繼承,抽象和 Mixin。更進一步學習了不同的類型的建構子如 Flutter 中會用到的具名建構子和工廠模式建構子。
這些知識、概念將協助我們更容易理解,閱讀關於 Flutter 開發相關的文件或範例。後續章節我們會開始探討 Flutter 的概念和一些底層原則。下一篇我們將從 Widget 開始,我們將立刻用到這些新知識。
with
關鍵字,你可以將一個或多個 mixins 混入到你的類別中。Point(this.x, this.y);
這樣的建構子可以直接將參數賦值給同名的實例欄位。List<String>
可以確保列表中的所有元素都是字符串。這樣做可以提高程式碼的類型安全性,減少運行時的錯誤。await
,則你的程式碼將在該方法完成之前暫停執行。這允許你等待非同步操作的結果。如果不使用 await
,你的程式碼將繼續執行,不會等待非同步方法完成。這可能導致你的程式碼在得到非同步方法的結果之前就繼續向下執行。